Примитивы синхронизации 2

Interlocked

int counter = 0;

// Атомарное увеличение
Interlocked.Increment(ref counter);

// Атомарное уменьшение
Interlocked.Decrement(ref counter);

Для атомарных операций над примитивами, работает на уровне команд процессора, поэтому только примитивы

Барьеры памяти и неблокирующая синхронизация в .NET

Компилятор имеет свойство менять местами операции в коде, а также проводить другие оптимизации, например погрузку переменных в регистры, данное свойство может сломать логику работы при многопоточке. (Более того, даже процессор такую штуку мутит)

Для предотвращения вредных оптимизаций существуют блокираторы памяти, они есть на уровне ассемблерных команд, а разработчик Dotnet может ими управлять с помощью ключевого слова Volatile

Пример аномалии, вызванной попаданием переменной в регистр, хотя она изменяется другим потоком. В теории код должен завершится, но он будет вечен.

private static int t;
static void Main()
{
    var e = Task.Run(f);
    Thread.Sleep(1000);
    t = 1;
    e.Wait();
}

static void f()
{
    t = 0;
    while (t == 0)
    {
    }
}

Если добавить к переменной ключевое слово volatile то код штатно отработает и завершится через +- секунду.

Существуют аномалии вызванные перестановкой инструкций программы, для начала представлю табличку, где описано какие инструкции могут поменяться местами. Процесс загрузки это чтение из памяти, а запись это запись

Тип перестановки Перестановка разрешена
Загрузка-загрузка Да
Загрузка-запись Да
Запись-загрузка Да
Запись-запись Нет

Для запрета перестановки инструкций, существуют барьеры памяти, их несколько видов

  1. Полный, гарантирует, что все чтения и записи расположенные до/после барьера будут выполнены так же до/после барьера, то есть никакая инструкция обращения к памяти не может перепрыгнуть барьер.
  2. release fence гарантирует, что инструкции, стоящие до барьера, не будут перемещены в позицию после барьера.
  3. accure fence гарантирует что инструкции, стоящие после барьера, не будут перемещены в позицию до барьера.

Термин volatile write означает выполнение записи в память в сочетании с созданием release fence

Термин volatile read означает чтение памяти в сочетании с созданием accure fence.

.NET предоставляет следующие методы работы с барьерами памяти:

  • метод Thread.MemoryBarrier() создает полный барьер памяти
  • ключевое слово volatile превращает каждую операцию над переменной, помеченной этим словом в volatile write или volatile read соответсвенно.
  • метод Thread.VolatileRead() выполняет volatile read (Если открыть исходники, класс Thread вызывает аналог из Volatile)
  • метод Thread.VolatileWrite() выполняет volatile write () (Если открыть исходники, класс Thread вызывает аналог из Volatile)

Рассмотрим, как реализованы эти методы:

private struct VolatileByte { public volatile byte Value; }

[Intrinsic]
[NonVersionable]
public static byte Read(ref byte location) =>
	Unsafe.As<byte, VolatileByte>(ref location).Value;

[Intrinsic]
[NonVersionable]
public static void Write(ref byte location, byte value) =>
	Unsafe.As<byte, VolatileByte>(ref location).Value = value;

Как можно заметить, на момент dotnet 6, эти методы работают избыточно, то есть, создается переменная с ключевым словом volatile и происходит чтение и запись сначала в неё, однако, как известно, volatile обеспечивает одновременно оба уровня защиты, а значит текущая реализация избыточна.

Также, стоит заметить, что ключевое слово volatile не дает полной защиты (как это делает полный барьер), тк эти две операции все равно могут быть переставлены без нарушения их условий:

Thread.VolatileWrite(b)
Thread.VolatileRead(a)

Создание экземпляров статических переменных уникальных между потоками.

Иногда, есть необходимость, чтобы переменная хранилась в рамках одного контекста выполнения, например при обработке запроса из API. Раньше, до async/await для этого применялся атрибут

[ThreadStatic] 
private static StringBuilder CachedInstance;

В таком случае, для каждого потока создается отдельная статическая переменная. Но с применением async/await выполнение программы может продолжить другой поток. Для сохранения переменной в рамках контекста выполнения между потоками применяется конструкция

private static AsyncLocal<string> _context = new AsyncLocal<string>();

При применении такого типа, на этапе выполнения, при переключении потока в стейтмашине, сохраняются все такие переменные в екземпляре класса ExecutionContext (не путать с контекстом потока ОС, этот контекст на уровне CLR). А при запуске снова стейтмашины, этот экземпляр ExecutionContext подбирается потоком и нужные значения присваиваются.
Данную конструкцию удобно применять например при логгировании в обработке Http